UnityUI用いい感じレイアウトライブラリ LayouTaro


概要

UUebVIewで大概人生においてのレイアウトエンジンを実装してみようミッションはこなしたはずだったが、

なんか必要があって実装した。


これ

LayouTaro

https://github.com/sassembla/LayouTaro



・Unityで扱えるUIを型で定義、生成し

・それを可変長引数に放り込むとレイアウトされる


というもの。

サンプルとして画像とテキスト、ボタンが使えるようにしてある。



動機

HTMLを書いて独自タグ定義すればいける、というのを目指したUUebViewは、あれはあれで一定の成功を収めている。

が、まーぶっちゃけ「Web専用の固定されたレイアウトを行う」ことに特化していて、自由度はない。


反面、ルールさえ書ければ独自のレイアウトができる、みたいな機構が欲しいというニーズは根強く存在していて、今回はこれを叶えたい。


・タグ定義ができて(LTElement)

・レイアウターを作成すれば

・独自タグで独自レイアウトができる

これが理想。で、かなりいい線まで、非常に小さいコードベースで行けたと思ってる。

文字だろうが画像だろうがボタンだろうが、所詮はTextとRectなので、それをレイアウトしたい、という比較的ベーシックな欲求も叶えられれば、

このタグはこういうレイアウトにしたい、必ず上に表示されて欲しい、ツールチップみたいにマウスオンしたら、、みたいなマニアックな要望まで、

LayouTaroは(しちめんどくさいレイアウトを書くことに人生を捧げるだけで)叶えてくれる。


Textとは、「連続する要素がレイアウト位置に応じて変形、改行される」もの。主に文字。

Rectとは、「要素がアトミックでレイアウトによる変形がない」もの。主に画像とか。



構成

LayouTaroアセットの全体的な構成は次の3層。

・レイアウトまでのフローをフレームワーク化したLayouTaro

・独自タグ(LTElement)

・LayouTaroで動かせるレイアウター


LayouTaroが提供するフローに則ると自作のタグを自作のレイアウターでレイアウトすることができるよ、という感じ。

右向き行単位での縦揃えと左下への改行を行う、という基礎的なレイアウト機能は、実装例を兼ねてBasicLayoutFunctionという名前で組み込み機能として定義されている。


読んでもらえばわかるが、レイアウトとはかなりファンキーだ。まあ、これはまだそれなりにかんたん。


独自のUIパーツを作った時に、それをどう並べるか、各要素が内容によって伸び縮みしたらどうなるか、

そういうのを解決していって欲しい。



使い方


1. 型の定義

    public enum LTElementType

    {

        Box,

        Image,

        Text,

        Button

    }

自分が足したいコンテンツの識別子(LTElementType)を LayouTaro/LayouTaroElements/LayoutElementType.cs に書く。



2. LTElementの定義

public class ButtonElement : LTElement, ILayoutableRect

{

    public override LTElementType GetLTElementType()

    {

        return LTElementType.Button;

    }


    public Image Image;

    public Action OnTapped;


    public static ButtonElement GO(Image image, Action onTapped)

    {

        // prefab名を固定してGOを作ってしまおう

        var prefabName = "LayouTaroPrefabs/Button";

        var res = Resources.Load(prefabName) as GameObject;

        var r = Instantiate(res).AddComponent<ButtonElement>();


        r.Image = image;

        r.OnTapped = onTapped;


        // このへんでレシーバセットする

        var button = r.GetComponent<Button>();

        button.onClick.AddListener(() => r.OnTapped());


        return r;

    }


    public Vector2 RectSize()

    {

        // ここで、最低でもこのサイズ、とか、ロード失敗したらこのサイズ、とかができる。

        var imageRect = this.GetComponent<RectTransform>().sizeDelta;

        return imageRect;

    }

}


UI要素として、LTElementを継承したクラスを定義する。LTElementはMonoBehaviourを継承しているので、このスクリプトはファイル名とクラス名を一致させる必要がある。


GetLTElementTypeで先ほど定義したLTElementTypeを返すコードを書く。


定義するUIパーツがRect(矩形)なのかText(文章)なのかで、追加で実装すべきインターフェースが決まる。

ここでは、画像を使うUIパーツを定義したいので、ILayoutableRectを持たせる。


で、このクラスをAddComponentした状態のGameObjectを返す関数を定義すると、あとが楽。



3. RootElementの定義

2と同様、LTRootElementを継承した型を作成する。

LTElementと異なるのは、GetLTElementsというLTElementのarrayを持たされること。

public class BoxElement : LTRootElement

{

    public override LTElementType GetLTElementType()

    {

        return LTElementType.Box;

    }


    public override LTElement[] GetLTElements()

    {

        return elements;

    }


    private LTElement[] elements;


    public Image BGImage;// 背景画像、9パッチにすると良さそう


    public static BoxElement GO(Image bg, params LTElement[] elements)

    {

        var prefabName = "LayouTaroPrefabs/Box";

        var res = Resources.Load(prefabName) as GameObject;

        var r = Instantiate(res).AddComponent<BoxElement>();


        r.BGImage = bg;

        r.elements = elements;


        return r;

    }

}



4. UIの生成とレイアウト

独自定義した要素を生成、Rootを親として子供要素をガンガン入れる。


        // データ構造を作る、自由に構造を書いていい。

        var box = BoxElement.GO(

            null,// bg画像

            () =>

            {

                Debug.Log("ルートがタップされた");

            },

            TextElement.GO("hannin is yasu! this is public problem! gooooooooooooood"),// テキスト

            ImageElement.GO(null),// 画像

            ButtonElement.GO(null, () => { Debug.Log("ボタンがタップされた"); })

        );


        // レイアウトに使うクラスを生成する

        var layouter = new MyLayouter();


        // コンテンツのサイズをセットする

        var size = new Vector2(200, 100);


        // レイアウトを行う

        var go = box.gameObject;

        go = LayouTaro.Layout<BoxElement>(

            canvas.transform,// レイアウトしたものを置く親のTransform

            size,// レイアウトするビューのサイズ。横幅はそのまま、縦に延びるのを想定している。

            go,// LTRootElement型を継承しているT型のComponentがセットされているGameObjectを指定する

            layouter// レイアウトに使用する操作法をセット

        );


こうして生成された、自由な順番 + 内容でLTElementを含んだboxと、自由にレイアウトを記述できるILayouterを拡張したMyLayouterのインスタンス、そして

コンテンツをレイアウトするためのサイズを用意する。


最終的に LayouTaro.Layout<LTRootElement型> メソッドを実行すると、LTRootElementを拡張した型のGameObjectの中身がレイアウトされる。


上のやつだと、

・幅200のコンテンツに、BoxElementを親として、子コンテンツとしてText(犯人はヤス)、Image(null)がレイアウトされる

という感じになる。



レイアウトコード

ILayouterを実装したクラスのメソッドがレイアウト時に自動的に呼ばれる。

そこで、自力で種類単位でのレイアウトをしてもよし、予め用意されているBasicLayoutFunctionsを利用することも可能。


ILayouterには次の2つのメソッドが定義されている。


Layout関数はLayouTaroのLayout関数から呼ばれる。 コンテンツのレイアウトをTypeEnumごとに事細かく描くことができる。

public void Layout(Vector2 viewSize, out float originX, out float originY, GameObject rootObject, LTRootElement rootElement, LTElement[] elements, ref float currentLineMaxHeight, ref List<RectTransform> lineContents)



UpdateValues関数はLayouTaroのRelayoutWithUpdate関数から呼ばれる。 レイアウト済みのオブジェクトに対し、任意の値をセットすることができる。

public void UpdateValues(LTElement[] elements, Dictionary<LTElementType, object> updateValues)



試しにLayouterを実装してみた場合の全体は以下のようになる。 ILayouterを継承している。

public class MyLayouter : ILayouter

{

    /*

        子要素をレイアウトし、親要素が余白ありでそれを包む。

    */

    public void Layout(Vector2 viewSize, out float originX, out float originY, GameObject rootObject, LTRootElement rootElement, LTElement[] elements, ref float currentLineMaxHeight, ref List<RectTransform> lineContents)

    {

        var OutsideSpacing = 10f;

        originX = 0f;

        originY = 0f;


        var originalViewWidth = viewSize.x;


        var viewWidth = viewSize.x - OutsideSpacing * 2;// 左右の余白分を引く


        // MyLayputrootとしてboxがくる前提で作られている、という想定のサンプル

        var root = rootObject.GetComponent<BoxElement>();

        var rootTrans = root.GetComponent<RectTransform>();


        for (var i = 0; i < elements.Length; i++)

        {

            var element = elements[i];


            var currentElementRectTrans = element.GetComponent<RectTransform>();

            var restWidth = viewWidth - originX;


            lineContents.Add(currentElementRectTrans);


            var type = element.GetLTElementType();

            switch (type)

            {

                case LTElementType.Image:

                    var imageElement = (ImageElement)element;


                    BasicLayoutFunctions.RectLayout(

                        imageElement,

                        currentElementRectTrans,

                        imageElement.RectSize(),

                        ref originX,

                        ref originY,

                        ref restWidth,

                        ref currentLineMaxHeight,

                        ref lineContents

                    );

                    break;

                case LTElementType.Text:

                    var newTailTextElement = (TextElement)element;

                    var contentText = newTailTextElement.Text();


                    BasicLayoutFunctions.TextLayout(

                        newTailTextElement,

                        contentText,

                        currentElementRectTrans,

                        viewWidth,

                        ref originX,

                        ref originY,

                        ref restWidth,

                        ref currentLineMaxHeight,

                        ref lineContents

                    );

                    break;

                case LTElementType.Button:

                    var buttonElement = (ButtonElement)element;


                    BasicLayoutFunctions.RectLayout(

                        buttonElement,

                        currentElementRectTrans,

                        buttonElement.RectSize(),

                        ref originX,

                        ref originY,

                        ref restWidth,

                        ref currentLineMaxHeight,

                        ref lineContents

                    );

                    break;


                case LTElementType.Box:

                    throw new Exception("unsupported layout:" + type);// 子のレイヤーにBoxが来るのを許可しない。


                default:

                    Debug.LogError("unsupported element type:" + type);

                    break;

            }

        }


        // 最終行のレイアウトを行う

        BasicLayoutFunctions.LayoutLastLine(ref originY, currentLineMaxHeight, ref lineContents);


        // サイズを調整する

        rootTrans.sizeDelta = new Vector2(originalViewWidth, Mathf.Abs(originY) + OutsideSpacing * 2);// オリジナル幅で、高さに対して2倍分の余白を足す。


        // 子要素の余白分の移動

        foreach (var e in elements)

        {

            var rectTrans = e.GetComponent<RectTransform>();

            rectTrans.anchoredPosition = new Vector2(rectTrans.anchoredPosition.x + OutsideSpacing, rectTrans.anchoredPosition.y - OutsideSpacing);// ルートの下のエレメントの要素をスペース分移動する。y-なので-する。

        }

    }


    public void UpdateValues(LTElement[] elements, Dictionary<LTElementType, object> updateValues)

    {

        foreach (var e in elements)

        {

            switch (e.GetLTElementType())

            {

                case LTElementType.Image:

                    var i = (ImageElement)e;


                    // get value from updateValues and cast to the type what you set.

                    var p = updateValues[LTElementType.Image] as Image;

                    i.Image = p;

                    break;

                case LTElementType.Text:

                    var t = (TextElement)e;


                    // get value from updateValues and cast to the type what you set.

                    var tVal = updateValues[LTElementType.Text] as string;

                    t.TextContent = tVal;

                    break;


                default:

                    break;

            }

        }

    }

}



実例

スクリーンショット 2020-03-24 18.14.10.png

こんな感じのレイアウトができたりする。はい。



いい感じ。